세 줄 요약

  1. prod CPU profile에서 Feature Flag 갱신 경로의 YAML parser가 전체 CPU의 약 4%를 차지하고 있었습니다.
  2. Redis retriever가 만든 JSON을 YAML로 다시 읽은 원인은 비어 있던 FileFormat이었습니다. 서비스에서는 FileFormat: "json" 한 줄로 경로를 바로잡았습니다.
  3. 배포 후 prod 6개 pod 표본에서는 YAML 함수가 검출되지 않았고 대응하는 JSON 변환 경로는 약 0.003%였습니다. 이 문제를 일반화한 FormatHinter도 go-feature-flag에 기여했습니다.

프로파일에 나타난 낯선 YAML 함수

2026년 5월, 서비스의 CPU profile을 Pyroscope에서 볼 수 있도록 수집 환경을 정리했습니다. 얼마 지나지 않아 낯선 함수 하나가 눈에 들어왔습니다.

gopkg.in/yaml.yaml_parser_state_machine이었습니다. 당시 prod profile 분석 기록에는 이 함수가 전체 CPU의 약 4%를 차지한다고 적혀 있었습니다.

변경 전 profile 원본은 보존 기간이 지나 다시 계산할 수 없습니다. 이 글의 4%는 당시 prod profile을 보고 남긴 수치입니다. 정확한 sample 단위와 pod 범위까지 복원한 값은 아닙니다.

그런데 서비스는 Feature Flag를 Redis에서 JSON으로 읽고 있었습니다. 설정과 데이터 어디에도 YAML을 쓸 이유가 없어 보였습니다.

JSON으로 가져온 데이터가 왜 YAML parser를 지나고 있을까?

에러는 없었고 Feature Flag 평가 결과도 정상이었습니다. 기능만 봤다면 지나쳤을 문제였습니다. CPU profile에는 성공한 코드가 치른 비용까지 남아 있었습니다.

Redis부터 YAML parser까지 호출 경로를 따라가기

Redis retriever가 무엇을 반환하는지부터 따라갔습니다. 당시 사용하던 go-feature-flag v1.41.3은 prefix에 맞는 key를 SCAN한 뒤 값을 하나씩 GET합니다.

retriever는 각 값을 json.Unmarshal로 읽습니다. 모은 flag map은 다시 json.Marshal[]byte로 반환합니다. 핵심만 남기면 이런 코드입니다.

go
for _, key := range keys {
    raw := redis.Get(key)
    json.Unmarshal([]byte(raw), &flag)
    flags[key] = flag
}

return json.Marshal(flags)

여기까지는 예상대로 JSON입니다. 문제는 이 값을 로컬 캐시의 Feature Flag 구조체로 바꾸는 다음 단계에 있었습니다.

당시 cache manager는 retriever 종류를 따지지 않고 전역 Config.FileFormat을 parser에 전달했습니다. FileFormat이 비어 있으면 default 분기의 yaml.Unmarshal을 호출했습니다.

go
switch fileFormat {
case "json":
    err = json.Unmarshal(data, &flags)
case "toml":
    err = toml.Unmarshal(data, &flags)
default:
    err = yaml.Unmarshal(data, &flags)
}

서비스는 FileFormat을 지정하지 않았습니다. Redis retriever가 JSON을 만들었는데도 전역 기본값을 따라 YAML parser가 선택됐습니다.

호출 경로를 이어 보면 아래와 같습니다.

bash
Redis keys (configured prefix + "*")
SCAN + GET
    │ 각 값 json.Unmarshal, 전체 map json.Marshal
JSON bytes
    │ global FileFormat (empty)
yaml.Unmarshal
Feature Flag DTO

이 파싱은 Feature Flag를 평가할 때마다 실행되지 않습니다. SDK가 시작될 때 한 번, 이후 polling이나 명시적인 refresh로 로컬 캐시를 갱신할 때 실행됩니다.

polling interval은 60초였고 jitter도 켜져 있었습니다. 프로세스 시작 시 ±10% 범위에서 주기가 정해져 실제 갱신은 약 54~66초마다 일어났습니다.

실행 빈도는 요청 수와 무관했지만 모든 pod에서 계속 반복됐습니다. 한 번의 비용은 작아도 프로세스가 오래 살아 있는 만큼 쌓였습니다.

잘못된 parser인데도 실패하지 않은 이유

JSON bytes를 YAML parser에 넘겼다고 하면 parse error부터 떠올리기 쉽습니다. 이번에는 멀쩡히 성공했습니다.

YAML 1.2는 JSON 호환성을 중요한 목표로 삼았습니다. 일반적인 JSON object와 array 표현은 YAML의 flow collection으로도 읽힙니다.

json
{"name": "example", "tags": ["feature", "flag"]}

이 입력은 JSON으로 유효하며 YAML parser도 같은 구조로 해석합니다. 잘못 고른 parser가 결과에서 티를 내지 않은 이유입니다.

일반적인 버그는 실패하기 때문에 발견됩니다. 이번 문제는 성공했기 때문에 profile을 보기 전까지 드러나지 않았습니다.

YAML parser가 더 많은 일을 한 이유

JSON parser도 입력을 훑으며 token과 상태를 관리합니다. JSON에는 상태 머신이 없고 YAML만 복잡한 것은 아닙니다.

차이는 다루는 문법의 범위입니다. JSON은 object, array, string, number처럼 비교적 제한된 문법을 처리합니다.

YAML은 block과 flow style, 들여쓰기, anchor, alias, tag, 여러 scalar 표현까지 고려해야 합니다.

항목JSONYAML
구조 표현명시적인 구분자들여쓰기와 flow/block style
지원 문법제한된 JSON 데이터 타입anchor, alias, tag, 다양한 scalar 표현
이번 입력원래 포맷읽을 수는 있지만 불필요하게 범용적

사용 중이던 go-yaml v3는 입력을 parse해 node를 구성한 뒤 대상 Go 값으로 decode합니다.

encoding/json도 자체 scanner와 decode 과정을 거치지만 이번 입력에는 JSON 문법만 처리하면 충분했습니다.

YAML이 언제나 몇 배 느리다고 일반화할 생각은 없었습니다. 차이는 payload의 flag 수와 byte 크기, 구조, 실행 환경에 따라 달라집니다.

실제 payload 규모를 모른 채 만든 benchmark는 합성 입력의 수치일 뿐입니다. 몇 배라는 숫자는 빼고 잘못된 parser가 호출됐다는 profile과 변경 후 경로를 확인했습니다.

해결은 한 줄이었지만 원인은 한 줄에서 보이지 않았다

서비스 쪽 수정은 간단했습니다. go-feature-flag 설정에 실제 입력 포맷을 명시했습니다.

diff
ffConfig := ffclient.Config{
    PollingInterval: 60 * time.Second,
+   FileFormat:      "json",
    Retriever:       &redisretriever.Retriever{...},
}

이제 Redis retriever가 만든 JSON bytes는 json.Unmarshal 분기로 바로 갑니다.

bash
Redis keys (configured prefix + "*")
    │ SCAN + GET
각 값 json.Unmarshal, 전체 map json.Marshal
JSON bytes
    │ FileFormat: "json"
json.Unmarshal
Feature Flag DTO

기존 테스트와 검증 환경에서 Feature Flag 동작도 다시 확인했습니다. 기능 회귀 여부와 CPU 비용은 같은 검증으로 증명되지 않습니다.

성능은 배포 후 profile에서 따로 확인했습니다.

어느 정도 개선됐다고 볼 수 있을까

변경 전과 같은 조건의 raw profile은 남아 있지 않았습니다. 과거의 약 4%와 현재 profile은 동일한 before/after 실험이 아닙니다.

배포가 충분히 지난 2026-08-02, prod의 최근 30분 CPU profile을 다시 확인했습니다.

전체 profile을 한 번에 합치는 조회는 실패했습니다. 정상적으로 받은 6개 pod profile을 합쳐 표본으로 삼았습니다.

표본의 전체 CPU sample은 2,831.47초였습니다. YAML 관련 함수는 검출되지 않았습니다.

ConvertToFlagStruct → encoding/json.Unmarshal의 누적 CPU는 0.08초, 전체의 0.0028%였습니다. 반올림하면 약 0.003%입니다.

구분변경 전 prod 기록변경 후 prod 표본
범위당시 profile, raw 원본 미보존30분, 6개 pod
parser 경로YAML parser 약 4%YAML 함수 미검출
대응 JSON 변환 경로확인 불가누적 0.08초 / 0.0028%

과거와 현재는 시점, workload, pod 범위가 다릅니다. 변경 전 raw profile도 없습니다. 서비스 전체 CPU가 정확히 4% 줄었다고 말할 근거는 부족합니다.

확인할 수 있는 범위는 여기까지입니다. 과거 prod에서 약 4%로 기록된 YAML 병목은 현재 표본에서 검출되지 않았습니다.

대응하는 JSON 변환 경로는 약 0.003%였습니다. 의도한 parser 경로 전환은 확인됐습니다.

전역 기본값을 JSON으로 바꿀 수 없는 이유

이 서비스에는 FileFormat: "json"이면 충분했습니다. 같은 해결책을 라이브러리 기본 동작으로 옮기는 일은 간단하지 않았습니다.

업스트림 해결책을 논의할 당시 go-feature-flag는 Redis 외에도 File, HTTP, MongoDB, PostgreSQL 같은 retriever를 지원했습니다.

Redis, MongoDB, PostgreSQL retriever는 결과를 JSON으로 직렬화합니다. File과 HTTP는 사용자가 제공한 원본 bytes를 그대로 전달하기도 합니다.

Retriever반환 포맷전역 JSON 강제
Redis / MongoDB / PostgreSQL구현상 JSON가능
File / HTTP 등사용자 원본 포맷불가능

기본 parser를 JSON으로 바꾸면 기존 YAML 파일을 쓰는 사용자가 영향을 받습니다.

cache manager에서 Redis 같은 구체 타입을 검사할 수도 있습니다. 그러면 새 retriever를 추가할 때마다 중앙 코드에 구현 세부사항이 새어 들어갑니다.

기존 Retriever 인터페이스에 포맷 메서드를 바로 추가하면 모든 사용자 구현체가 새 메서드를 구현해야 합니다. 하위 호환성을 깨뜨리는 변경입니다.

서비스에서 통했던 한 줄은 라이브러리의 범용 해법이 아니었습니다.

출력 포맷의 소유권을 retriever로 옮기기

업스트림에는 optional interface FormatHinter를 제안했습니다.

go
type FormatHinter interface {
    OutputFormat() string
}

cache manager는 retriever가 FormatHinter를 구현했는지 확인합니다. 값이 있으면 hint를 먼저 쓰고 구현하지 않았거나 빈 값을 반환하면 기존 전역 FileFormat으로 돌아갑니다.

go
func getOutputFormat(r Retriever, fileFormat string) string {
    if hinter, ok := r.(FormatHinter); ok {
        if hint := hinter.OutputFormat(); hint != "" {
            return hint
        }
    }
    return fileFormat
}

Redis, MongoDB, PostgreSQL retriever만 json을 알립니다. File과 HTTP를 포함한 나머지는 기존 설정을 그대로 따릅니다.

기존 Retriever 인터페이스는 바꾸지 않았습니다. 사용자가 만든 retriever의 source compatibility를 지키면서 출력 포맷을 아는 구현체만 정보 하나를 더 제공합니다.

처음에는 parser의 전역 설정 하나가 빠진 문제로 보였습니다. 호출 경로를 끝까지 따라가니 bytes를 생산하는 retriever가 출력 포맷을 가장 정확히 알고 있었습니다.

출력 포맷은 전역 parser 설정이라기보다 데이터를 생산하는 retriever의 속성에 가깝습니다.

포맷 문자열이 아니라 실제 parser 선택을 테스트하기

OutputFormat()json을 반환하는지만 확인하면 구현체의 선언만 검증합니다. cache manager가 실제 parser 선택에 그 값을 쓰는지는 알 수 없습니다.

manager 테스트에서는 일부러 설정을 충돌시켰습니다. payload는 TOML로 만들고 전역 FileFormat은 YAML, retriever hint는 TOML로 뒀습니다.

전역 설정을 따르면 parsing에 실패하고 hint를 먼저 쓸 때만 성공합니다. 단순한 문자열 반환이 아니라 parser 선택의 우선순위를 검증하는 구조입니다.

hint가 비어 있으면 전역 YAML 설정으로 돌아가 parsing에 실패하는 경우도 확인했습니다. Redis, MongoDB, PostgreSQL 테스트는 각 retriever가 JSON을 선언하는지만 확인했습니다.

구현 세부사항보다 실제로 지킬 계약을 기준으로 테스트를 나눴습니다.

서비스의 한 줄에서 오픈소스 설계까지

날짜로 놓고 보면 이렇습니다.

  1. 2026-05-15: Pyroscope 수집 환경 구축
  2. 2026-05-19: 서비스에 JSON FileFormat 명시
  3. 2026-05-23: go-feature-flag Issue와 PR 작성
  4. 2026-05-28: FormatHinter 변경 merge
  5. 2026-06-04 KST: v1.54.0에 최초 포함

이 문제는 parser 지식보다 관측 가능성이 먼저였습니다. 기능이 정상인 경로도 profile을 열어 보면 예상하지 못한 비용이 드러납니다.

서비스에서는 한 줄로 빠르게 멈췄지만 그 값을 라이브러리의 기본값으로 옮길 수는 없었습니다.

다양한 입력을 받는 라이브러리에서는 국소적인 workaround와 범용적인 설계를 구분해야 했습니다.

이번에는 bytes를 만든 retriever가 포맷을 가장 정확히 알았습니다.

그 책임을 optional interface로 드러내 하위 호환성을 유지했고 불필요한 YAML parsing도 없앴습니다.

프로덕션에서 발견한 설정 한 줄은 데이터 포맷의 책임을 어디에 둘 것인가라는 설계 문제로 이어졌습니다.

이 변경은 2026년 5월 28일에 merge됐고 go-feature-flag v1.54.0에 처음 포함됐습니다.

Reference